Skip to content

Story 5.6: the blocker proposes every pair, and the floor is an integer - #59

Merged
guycorbaz merged 3 commits into
masterfrom
story-5.6-blocker-and-recall-assertion
Aug 1, 2026
Merged

Story 5.6: the blocker proposes every pair, and the floor is an integer#59
guycorbaz merged 3 commits into
masterfrom
story-5.6-blocker-and-recall-assertion

Conversation

@guycorbaz

Copy link
Copy Markdown
Owner

The candidate generator is now an explicit component with a measured recall floor, so a false split
cannot be born silently before any rule has had a chance to speak.

What ships

crates/opencmdb-core/src/identity/blocking.rs — the file the architecture already names
[architecture.md:3368] — holds:

  • CandidatePair, private fields ordered by its constructor, so new(a, b) == new(b, a) holds
    because the two calls build the same value. new(a, a) is None: the self-pair precondition,
    which decide_pair's doc left ownerless, finally has a holder — in the type, not in a comment.
  • candidates(&[Observation]) -> BTreeSet<CandidatePair> — every unordered pair of DISTINCT
    obs_ids, TOTAL by decision. D13 disposes of the performance argument itself ("the blocker is
    not there for performance — 90k pairs is noise on a NAS i5"
    ), and it calls neither join nor
    decide_pair: proposing is not judging, and a blocker that consults a rule is that rule's echo.
  • BLOCKING_RECALL_FLOOR_PER_MILLE: u32 = 999 and blocking_recall_per_mille, returning
    Option<u32>None for an empty denominator, because a recall with no denominator is
    undefined, not perfect.

The float was typed, not avoided

D13 hands this story blocking_recall >= 0.999, which is a bare float literal that reds the
float-free gate — measured during story 5.5 and flagged forward on purpose. The gate is not
weakened, skipped, #[allow]ed or narrowed: it walks 4 .rs files under identity/ now where
it walked 3. The value is D13's, unchanged; only its TYPE moved, on D13's own milli-units corollary
("an INTEGER in milli-units (0..1000), never REAL/DOUBLE") and on the architecture's ratified test
name blocking_recall_above_999, which already carried no float and is used verbatim.

Why this is not the recall gate D18 refuses by name

D18 puts pairwise recall in Tier 2, blocking nothing, because "a gate that cannot fall is
decoration"
. The module doc owes and carries the distinction: different subject (generator input
coverage, not engine output), different venue (a unit test over the frozen corpus, not a release
gate) — and the honest arithmetic that at a 10-pair denominator >= 999‰ IS zero-tolerance, and
becomes a real tolerance only past 1000 required pairs, where NFR4's "any fraction is theatre"
would bite. That day is registered, not inherited. Nothing here advances NFR4.

The corpus is the truth set, and its counts are asserted

Because D47 forbids the domain crate to read files, the corpus half lives in opencmdb-bin's
fixtures.rs test module — nothing above #[cfg(test)] changed and no new pub item appears in
bin. Measured by the shipped assertions, not quoted: 24 traps, 23 naming a pair, 1 residue
(example-must-abstain), 0 naming more than two, 10 must-merge pairs, recall 1000‰.

blocking_recall_above_999 computes only the recall; the per-trap containment assertion is a
separate test. That split is load-bearing and was measured: in one test, a missing pair panics on
docker-veth-must-merge before any recall is computed, and the number you would report is the panic.

Prove-to-red

Six mutations, run against a committed baseline, each restore verified by md5sum and
git status. Zero compiler-carried reds.

# mutation observed reds
M1 blocking on a shared L1 key 700‰ exactly, as measured at contexting 7 (3 bin, 4 core)
M2 blocking on l2_domain the whole corpus stays GREEN, 139/139 1 core
M3 the self-pair admitted 2 reds; the n*(n-1)/2 count test correctly does NOT red 2 core
M4 pair ordered by argument order corpus entirely green — corpus-invisible 3 core
M5 Some(1000) on an empty required the None case reds 1 core
M6 floor weakened to 900 only the independent-literal test reds 1 core

M2 is the one that matters: all 23 committed trap pairs are same-scope, so a l2_domain-blocked
blocker scores a full 1000‰ and is invisible to every corpus test. One synthetic cross-domain test
stands between that wrong blocker and green — which is why the story required it to be written first.

Three divergences from the predictions are stated rather than left implicit: M4's bin count is 139,
not the predicted 138 (same verdict, one more test); M1's bin red set is 3, not 2, the extra being
the containment test that did not exist at contexting; M1's core red set is 4, where the story
estimated "several".

Docs and register

The five falsified doc sites are corrected, plus a sixth found by re-reading and not by grep
identity/mod.rs's D54 paragraph, which turned out to be true (verdict_for_pair is
pub(crate), which is the crate and not the subtree), so it was verified and annotated rather than
rewritten. Register: R1 (the float) and R2 (the self-pair) disposed of by TITLE; three new
entries opened (D17's dormant exclusion, unimplementable because no lifecycle state exists —
measured; the quadratic universe; the floor's arithmetic past 1000 pairs); four entries belonging to
other stories read and left open, including the &str allocation one whose condition is measured
not met, since candidates calls no rule.

Numbers

309 → 332 tests (139 bin + 147 core + 46 xtask), re-counted mechanically after the last edit.
Six green gates plus ℹ views-hash STALE (exit 0 by design, issue #50). fixtures/ and the planning
artifacts are byte-unchanged.

What this does NOT do

No verdict is produced, no Decision is built, and decide/decide_pair/verdict_for_pair are not
called. trap_gate.rs was not opened; no From<Decision> for Outcome appears (5.7). The blocker
has no production caller
— it is reached from its own tests and from fixtures.rs's test module,
the same shape decide was in before story 5.5.

🤖 Generated with Claude Code

https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF

guycorbaz and others added 3 commits August 1, 2026 00:42
The candidate generator is now an explicit component. `candidates()` returns
every unordered pair of DISTINCT observation ids as a `BTreeSet<CandidatePair>`,
total by decision rather than by omission: D13 disposes of the performance
argument itself (90k pairs at 300 hosts is noise), and every exclusion a blocker
makes is a false split it can never be talked out of.

`CandidatePair` has private fields ordered by its constructor, so `new(a, b) ==
new(b, a)` holds by construction; `new(a, a)` is `None`, which gives the
self-pair precondition its first holder — `decide_pair` answers it today and
says only that the pair "arrives as an argument".

D13's `blocking_recall >= 0.999` ships as `BLOCKING_RECALL_FLOOR_PER_MILLE: u32
= 999`, on D13's own milli-units corollary and on the architecture's ratified
test name, which already carries no float. The `float-free` gate walks 4 files
under identity/ now, unweakened.

Measured against the committed corpus, in fixtures.rs's test module because the
domain crate may not read files (D47): 24 traps, 23 name a pair, 10 must-merge,
recall 1000 per-mille. The containment assertion and the recall value are two
tests on purpose — in one, a missing pair panics before any recall is computed.

309 -> 332 tests (139 bin + 147 core + 46 xtask). Six gates green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF
The five falsified doc sites are corrected — identity/mod.rs, cascade.rs's
Decision, l1.rs twice, and lib.rs, whose stated reason for not flat-re-exporting
join ('its consumer, the candidate generator, does not exist yet') is exactly
what this story falsifies. A sixth site was found by RE-READING rather than by
grep: identity/mod.rs's D54 paragraph, which turned out to be TRUE
(verdict_for_pair is pub(crate) — the crate, not the subtree), so it was
verified and annotated instead of rewritten.

Register: R1 (the float) and R2 (the self-pair) disposed of by TITLE, three new
entries opened, and four belonging to other stories read and left open — the
&str allocation entry's condition is measured NOT met, since candidates calls no
rule at all.

All six mutations run against a committed baseline, every restore verified by
md5sum and git status. Zero compiler-carried reds. Three divergences from the
predictions are stated rather than left implicit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF
Three review layers (Blind Hunter, Edge Case Hunter, Acceptance Auditor).
Nine patches applied, two deferrals registered with owners, eleven findings
dismissed after re-measurement. 332 -> 333 tests (139 bin + 148 core + 46
xtask); six gates green, `float-free` still over 4 files, corpus 25/25.

The HIGH was a doc refuted by a measurement inside the same commit. The
module head claimed blocking on the MAC, the hostname OR the `l2_domain`
would each pass the whole committed corpus. Measured over the ten
`must-merge` pairs by parsing the TOML and JSONL directly: MAC 7/10 = 700
per-mille — which is the story's OWN M1 figure, twelve lines below the
sentence it refutes — hostname 4/10 = 400, `l2_domain` 10/10 = 1000. Only
the third passes, and it is the one the corpus is blind to. The head doc now
carries the three figures.

The one behavioural gap: nothing fed `candidates` an observation with EMPTY
`facts`, so a `facts.is_empty()` narrowing was measured GREEN across all 332
tests — the same mutation class as M2, but M2 has a synthetic test and this
had nothing. 0 of the 51 committed observations has empty facts, so the
corpus could never have caught it. One test added, proven red against it.

Six of the nine patches were sentences rather than behaviour, the fifth
story running with that shape: `decide_pair(a, a)` was documented as
answering with a merge when it abstains on a MAC-less observation (two doc
sites plus the register); three sites named the trap runner as the blocker's
consumer in the present tense while the register in the same commit said it
has no production caller at all; the register announced three new entries
and opened four; and the floor's tolerance boundary was off by one — it
opens AT 1000 required pairs, not above.

Two behavioural weakenings fixed: the floor assertion now runs before the
exact-value one, so a narrowed blocker reds with D13's threshold rather than
a bare equality (`>=` to `>` had left the workspace green), and the superset
test asserts its exact three grouped pairs.

Three prove-to-red runs on what the patches changed, restores verified by
md5sum, all assertion-carried, zero compiler-carried:
  MV1 fact-presence narrowing -> the new test reds alone (1 core)
  MV2 superset fixture degraded -> `checked` measured at 1, so the old
      `assert!(checked > 0)` was green there; `assert_eq!(checked, 3)` reds
  MV3 one required pair dropped -> "recall is 900 per-mille, below the
      floor of 999" (1 bin)

Status stays `review`: `done` is the merge's business in this project's
flow, as 5.1 through 5.5 all decided.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF
@guycorbaz
guycorbaz merged commit 020c706 into master Aug 1, 2026
1 check passed
@guycorbaz
guycorbaz deleted the story-5.6-blocker-and-recall-assertion branch August 1, 2026 09:44
guycorbaz added a commit that referenced this pull request Aug 1, 2026
Bookkeeping only — **no code changes**. Follows the same shape as #56
(5.4b) and #58 (5.5).

- `sprint-status.yaml`: `5-6-blocker-and-recall-assertion: review →
done`, with the review's outcome recorded above the entry.
- The story file's `Status:` follows.
- `CLAUDE.md` and `docs/project-context.md` now describe what **merged**
(`020c706`) rather than what was in flight, and Epic 5 reads **8 of 16
done**.

**5.6 was code-reviewed before the merge** (three layers: Blind Hunter,
Edge Case Hunter, Acceptance Auditor): **9 patches applied, 2 deferrals
registered with owners, 11 findings dismissed after re-measurement**.
332 → **333 tests**.

The docs carry the two measured findings rather than a summary of them:

- **The HIGH is a doc refuted by a measurement inside the same commit.**
`blocking.rs`'s head claimed blocking on the MAC, the hostname *or* the
`l2_domain` would each pass the whole committed corpus. Measured over
the ten `must-merge` pairs: MAC **7/10 = 700‰** — which is the story's
own M1 figure, twelve lines below the sentence it refutes — hostname
**4/10 = 400‰**, `l2_domain` **10/10 = 1000‰**. Only the third passes,
and it is the one the corpus is blind to.
- **The one behavioural gap:** nothing fed `candidates` an observation
with **empty `facts`**, so a `facts.is_empty()` narrowing was measured
GREEN across all 332 tests. 0 of the 51 committed observations has empty
facts, so the corpus could never have caught it. One test added, proven
red against it.

Six of the nine patches were **sentences, not behaviour** — the fifth
story running with that shape.

`views-hash` remains `ℹ STALE` by design (issue #50, a milestone act).

🤖 Generated with [Claude Code](https://claude.com/claude-code)

https://claude.ai/code/session_01HEE7mz4xufqYW5xKfgJBaF

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant